
我曾經有一個 agent,設定檔裡明明白白寫著它不能用某個工具。
[capabilities]
denied_tools = ["shell_exec"]
它用了。而且用得很順,沒有任何錯誤。
查了半天才發現:這個設定確實有被讀取,也確實有生效,但只生效在一個地方——啟動 CLI 的時候,它被翻譯成 --disallowedTools 傳給了 Claude Code。所以走 CLI 那條路的呼叫被擋住了。
而這個 agent 走的是另一條路:它透過 MCP 直接呼叫我自己的 server。那條路上,denied_tools 沒有任何一行程式碼在讀它。
設定是對的,執行是錯的,中間少了一道門。
工具管理有三個層次,很多人(包括三個月前的我)把它們混為一談。
| 面 | 問題 | 出錯的後果 |
|---|---|---|
| 宣告面 | 模型知道有哪些工具? | 太多:貴。太少:能力缺失 |
| 分派面 | 這個呼叫要送到哪個 handler? | 送錯地方 |
| 執行面 | 這個呼叫者有權做這件事嗎? | 安全漏洞 |
tools/list 是宣告面。它決定模型的視野,跟權限完全沒有關係。
我犯的錯是以為「不宣告就等於禁用」。這在直覺上很合理:模型不知道有這個工具,怎麼會呼叫它?
實務上有三個破口。
破口一:模型會猜。
工具名稱是有規律的。如果模型看過 task_list、task_create、task_update,它完全有可能自己拼出一個 task_delete 來試。我親眼看過這種事發生,模型呼叫了一個不在清單裡的工具名稱,理由是「照命名慣例應該有這個」。
如果你的 server 收到未知工具名稱時,是走到某個 getattr(self, name) 之類的動態分派,那就直接被打穿了。
破口二:清單會過期。
tools/list 通常在會話開始時抓一次。如果會話中途權限變了(授權被撤銷、任務結束、TTL 到期),模型手上那份清單還是舊的。它會照著舊清單呼叫,而你需要在執行的那一刻拒絕它。
我在自己的系統裡實作過一個「任務範圍授權」:某些工具需要一張有效的授權票,任務進入任何終止狀態(完成、拒絕、取消、升級給人)時,所有票立刻作廢。這種機制只有在執行面檢查才有意義,宣告面的過濾完全幫不上忙。
破口三:有別條路。
這就是我開頭那個 bug。同一個工具可能有多個進入點:CLI 旗標一條、MCP 分派一條、內部程式呼叫一條、HTTP API 又一條。你在其中一條加了檢查,其他三條還開著。

宣告面(DECLARE)決定模型看得到什麼,它只管省 token。真正擋人的是後面兩個菱形:分派面(DISPATCH)認不得的工具直接拒絕,執行面(AUTHORIZE)檢查這個呼叫者有沒有權限。兩道都會留下稽核紀錄。
一句話總結:宣告面省錢,執行面保命。
還有一條配套規則:可發現的集合,必須是可呼叫集合的子集合。你可以隱藏一個能用的工具(省 token),但不能宣告一個會被拒絕的工具。後者會讓模型不斷嘗試、不斷被拒、不斷重試,浪費好幾輪對話,而它永遠不知道為什麼。
關鍵設計是單一檢查點。不要在每個 handler 裡各寫一份權限檢查,那樣遲早會漏掉一個。
from dataclasses import dataclass
@dataclass(frozen=True)
class Caller:
"""呼叫者身分。所有進入點都必須提供這個。"""
agent_id: str
scopes: frozenset[str] # 這個呼叫者有哪些能力
denied_tools: frozenset[str] # 明確禁止的工具
# 工具 -> 需要的 scope。沒列在這裡的工具一律需要 admin。
TOOL_SCOPES = {
"task_list": "task:read",
"task_update": "task:write",
"shell_exec": "system:admin",
}
class ToolDenied(Exception):
pass
def authorize(caller: Caller, tool: str) -> None:
"""單一檢查點。任何進入點都要先過這裡。fail-closed。"""
if tool in caller.denied_tools:
audit(caller, tool, "denied_by_config")
raise ToolDenied(f"工具 {tool} 已被明確禁用")
required = TOOL_SCOPES.get(tool)
if required is None:
# 未列舉的工具 = 未知工具 = 拒絕。這是 fail-closed 的核心
audit(caller, tool, "unknown_tool")
raise ToolDenied(f"未知工具:{tool}")
if required not in caller.scopes:
audit(caller, tool, "missing_scope", required=required)
raise ToolDenied(f"缺少權限:{required}")
def dispatch(caller: Caller, tool: str, args: dict) -> dict:
try:
authorize(caller, tool) # ← 唯一的門
except ToolDenied as e:
return {"content": [{"type": "text", "text": str(e)}], "isError": True}
return HANDLERS[tool](args)
TOOL_SCOPES.get(tool) 回傳 None 時直接拒絕,這一行是整段程式的重點。
新加一個工具的人,如果忘記在這張表裡登記,結果是這個工具不能用(有人會來抱怨,你去補上)。反過來設計的話,忘記登記的結果是這個工具誰都能用(沒有人會抱怨,你永遠不知道)。
安全設計的預設值要選那個會有人抱怨的方向。
上面每個拒絕分支都呼叫了 audit()。這件事我一開始沒做,後來吃了苦頭:使用者問「為什麼 agent 說它不能做這件事」,我完全查不到,因為拒絕發生在一個沒有任何紀錄的分支裡。
import json, time, pathlib
AUDIT = pathlib.Path.home() / ".myagent" / "tool_calls.jsonl"
def audit(caller: Caller, tool: str, outcome: str, **extra) -> None:
rec = {
"ts": time.time(),
"agent": caller.agent_id,
"tool": tool,
"outcome": outcome, # ok / denied_by_config / unknown_tool / ...
**extra,
}
with AUDIT.open("a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
成功的呼叫也要記。這份檔案後來在我的系統裡變得非常重要,它是唯一能回答「這個 agent 到底做過什麼」的證據來源。Day 18 整篇都在講它。
一個實務提醒:這份檔案會長很快,而且裡面可能有敏感內容(工具參數、回傳結果)。要做三件事:輪替(我設 16MB)、遮罩(金鑰、token 在寫入前先過一次遮罩)、權限(chmod 600)。遮罩要在截斷之前做,順序反了會把密鑰的前半截原封不動寫進去。
執行面顧好之後,宣告面就可以放心地為了省錢而積極過濾:
def handle_tools_list(caller: Caller) -> list[dict]:
"""只宣告這個呼叫者真的能用的工具(可發現 ⊆ 可呼叫)。"""
out = []
for tool in ALL_TOOLS:
try:
authorize(caller, tool["name"]) # 重用同一個判斷
except ToolDenied:
continue
out.append(tool)
return out
重用 authorize 是刻意的。如果宣告面自己寫一套過濾邏輯,兩套邏輯遲早會分岔,然後你就會宣告出一個呼叫時會被拒絕的工具。
過濾得太積極,模型會不知道自己能做什麼。 我有一個 agent 因為過濾規則寫太緊,看不到任何寫入類工具,於是它每次都回答「我沒有權限修改,請你自己改」。使用者以為系統壞了。後來我在 system prompt 裡補了一句說明它的角色是唯讀顧問,抱怨就停了。能力邊界要讓模型知道,不然它會把「我看不到工具」表達成「這個系統做不到」。
每次呼叫都跑一次授權,有成本。 我的實作裡權限來源是一份設定檔,每次呼叫都重讀,加上檢查大約 0.1 毫秒。相對於一次 LLM 呼叫的幾秒鐘,這完全不值得優化。我刻意不快取,因為權限撤銷要立刻生效,晚一次呼叫都不行。
明天算一筆更大的帳:我的 server 現在有 200 個以上的工具,全部宣告出去要多少錢,以及怎麼在不砍功能的前提下把這個數字壓下來。